Skip to content

feat: add a per step forced discharge demand - #138

Open
andig wants to merge 3 commits into
mainfrom
feat/force-discharge
Open

andig wants to merge 3 commits into
mainfrom
feat/force-discharge

Conversation

@andig

@andig andig commented Aug 8, 2026 •

Copy link
Copy Markdown
Member

Follow-up to the optimizer TODO in evcc-io/evcc#31995 ("optimizer (MIP) integration to plan export windows from price spreads — deliberately out of scope; this PR is the mode + site-logic foundation"). That PR added the api.BatteryDischarge mode and batteryGridDischargeLimit; this one gives the optimizer a way to be told about it.

API

New optional per-battery field d_demand, the mirror of the existing p_demand:

"d_demand": [0, 0, 2500, 2500, 0, 0]   // discharge energy per time step (Wh)

Added to the flask-restx model, the request parsing, the time-series length validation, openapi.yaml, and the generated Go client (go generate ./...).

Model

  • new penalty variable d_demand_pen[i][t], only for batteries that carry a demand
  • constraint d[i][t] + d_demand_pen[i][t] >= min(d_max * dt/3600, d_demand[t])
  • penalty -prc_p_goal_pen * d_demand_pen[i][t] in the objective, no time weighting: a forced discharge names the steps it wants itself

Deliberately soft, like the charge demand it mirrors. A demand the battery cannot serve — already empty, d_max too small, the energy needed by the house — leaves an unserved remainder rather than turning the request infeasible. prc_p_goal_pen sits an order below prc_soc_exc_pen, so the forced discharge stops at the s_min reserve instead of draining through it, which is the same reserve guard applyBatteryMode enforces on the evcc side.

Reaching the grid still requires discharge_to_grid; the demand does not override it.

Tests

tests/test_force_discharge.py: no demand leaves the battery alone, a demand is served against an unfavourable price spread, clipping to d_max, stopping at s_min, and staying solvable when there is no sink for the energy. Full suite green (103 passed), ruff clean.

Review follow-up

  • A step with both p_demand and d_demand above zero is rejected with a 400 naming the battery and the steps.
  • z_s_min_reached releases the demand once the battery sits at s_min, the mirror of z_s_max_reached for the charge demand, so the penalty no longer keeps pulling at an empty battery.
  • Data driven cases 029-forced-discharge and 030-forced-discharge-stops-at-reserve in test_cases/, both strict.

🤖 Generated with Claude Code

evcc-io/evcc#31995 adds a forced discharge battery mode for feed-in arbitrage
and leaves the planning side open ("optimizer (MIP) integration to plan export
windows from price spreads - deliberately out of scope"). This is the request
side of that: a battery may now carry d_demand, the discharge energy per time
step it is asked to deliver, mirroring the existing p_demand.

The constraint is soft, like the charge demand it mirrors. A demand the battery
cannot serve - already empty, d_max too small, the energy needed by the house -
leaves an unserved remainder in the penalty variable instead of turning the
request infeasible, and the penalty stays an order below prc_soc_exc_pen so a
forced discharge stops at the s_min reserve rather than draining through it.
The demand is clipped to d_max per step; reaching the grid still requires
discharge_to_grid, the demand does not override it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@andig
andig requested a review from ekkea August 8, 2026 15:00

@ekkea ekkea left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

two things missing:

  • missing check: p_demand and d_demand being >0 at the same time needs to be closed out. What shall happen? Reject and send 500?
  • there is no precaution that d_demand may run into SOC becoming 0. Keeping up the penalty once this happens will lead to weird behavior as we have seen this with p_demand previously.
  • i strongly recommend to add standard test cases (in form of requests that use this feature, added to the test_cases folder). They are much easier to review than specific test scripts.

andig and others added 2 commits October 4, 2026 15:11
…ands

A demand the battery cannot serve kept its penalty once the battery sat at s_min, the same pull at an empty battery the charge demand once had at a full one. z_s_min_reached releases the demand there. p_demand and d_demand in the same step are rejected with a 400 naming the battery and steps. Two data driven cases cover the served demand and the stop at the reserve.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@andig

andig commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

@ekkea all three addressed in 91b6186, and the branch is merged up with main.

  • Overlap: a step with both p_demand and d_demand above zero is a 400, not a 500. It is a client error like a length mismatch, and the response names the battery and the steps.
  • SoC: z_s_min_reached releases the demand once the battery sits at s_min, the same construction as z_s_max_reached for the charge demand. test_discharge_demand_stops_at_the_s_min_reserve asserts the penalty variable is zero there.
  • Standard cases: test_cases/029-forced-discharge.json and 030-forced-discharge-stops-at-reserve.json, both strict so the schedule itself is compared.

🤖 Generated with Claude Code

@andig

andig commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Solve time, stored cases, three runs each, median, on this branch.

Requests without d_demand are unchanged: the model is untouched, the only addition is an O(T) overlap check in the parser. All 20 stored cases take 5.2 s on main and 5.4 s here, noise.

Requests with d_demand pay for one binary per demand step, z_s_min_reached. The control row has discharge_to_grid on but no demand, so the flag itself is separated out.

case slots control demand slots solve
028 long horizon 245 2.3 s 24 (2 h per day) 3.9 s
48 (4 h per day) 17.5 s
61 (every 4th) 31.4 s
020 night 154 1.5 s 16 3.3 s
32 5.0 s

This is the price of the #95 construction, not of the mirror. p_demand at the same density on the same battery, c_max/4 every fourth slot, costs 9.3 s on 028 and 9.3 s on 020. The stored cases never show it because their p_demand sits on batteries with 36 to 77 kWh against 11 kW, so the demand is always servable and the relaxation never needs the binary. Stress either demand against a battery that runs out and branch and bound pays the same.

The weakness is the big-M relaxation: z goes fractional in proportion to the distance from the bound, so the relaxation releases half the penalty at half capacity. A release without a binary needs min(demand, available) as a lower bound, a disjunction, so there is no cheaper exact form. Dropping the binary brings back the lingering penalty and with it the solver grid charging the battery to serve the forced export, the mirror of what #95 fixed.

What remains: the evcc side sends d_demand only for slots at or above the feed-in limit with the opt-in set, a few hours a day, so the 2 h per day row is the realistic one. Replanning every cycle also allows capping the demand to the nearest slots there. OPTIMIZER_TIME_LIMIT is unset by default and would turn a long solve into a Feasible result instead of a stall.

🤖 Generated with Claude Code

@andig

andig commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Two alternatives to the per step release binary, prototyped on this branch behind a switch, three runs each, median. Controls without any demand: 028 at 2.3 s, 020 at 1.5 s.

case, demand pattern binary (this PR) monotone release export bonus (LP)
028, 2 h per day, 24 slots 4.1 s 3.6 s 2.0 s
028, 4 h per day, 48 slots 17.8 s 10.0 s 2.7 s
020, 2 h per day 3.3 s 4.6 s 1.6 s
020, 4 h per day 4.9 s 2.8 s 1.4 s
028, every 4th slot, 61 slots 31.1 s 32.1 s 70.5 s
020, every 4th slot 4.6 s 4.6 s 1.4 s

Monotone release keeps the binary and adds z_t >= z_{t-1} inside a demand block. Halves the 4 h case on 028, nothing on the every-4th case, mixed on 020, and it would keep a battery released after PV refills it mid block. Not worth it.

Export bonus drops the binary and the penalty: d_bonus <= d, d_bonus <= min(d_demand, d_max dt), and the objective gains (max(0, p_a - p_E[t]) + 0.1 penalty_base) * d_bonus in the demand steps. Pure LP. Realistic patterns land at the no-demand control. Cases 029 and 030 produce the identical schedule and objective, so served, clipped to d_max, stop at s_min and no-sink all hold, and an empty battery has nothing to earn, so the pathology the binary guards against cannot occur.

Two caveats. The adversarial every-4th 028 case goes to 70 s, all in the joint stage: the bonus interacts with that case's peak attenuation and p_demand binaries rather than adding its own. And the semantics shift from forced to preferred: the solver skips a demand slot when serving it costs more than the bonus. On 028 that gave a clean objective of 4.98 against 4.30, some forced exports the binary version performed were dropped.

The same trade exists for p_demand: a charge bonus on grid-to-battery energy in the cheap slots would replace z_s_max_reached and z_p_demand, with the same shift to preferred.

My take: the bonus fits d_demand, the evcc grid discharge limit is a preference by nature and its slots are few. p_demand stays as is until its own cases show the cost. @ekkea if you agree I replace the binary here with the bonus and regenerate the two cases.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants